Skip to content

feat: outbound SIP registration (REGISTER) - #774

Open
skyzer wants to merge 3 commits into
livekit:mainfrom
skyzer:outbound-register
Open

feat: outbound SIP registration (REGISTER)#774
skyzer wants to merge 3 commits into
livekit:mainfrom
skyzer:outbound-register

Conversation

@skyzer

@skyzer skyzer commented Aug 4, 2026

Copy link
Copy Markdown

Problem

LiveKit SIP can only be an INVITE-only endpoint: a provider has to know a stable address to send inbound INVITEs to. Providers commonly offer a second inbound mode for endpoints that do not have one — the endpoint sends REGISTER, and calls are delivered to the resulting binding. DIDWW, for example, offers it explicitly for a PBX or SBC that "has a dynamic IP address, operates behind NAT, or when you prefer registration-based routing". Without REGISTER, a self-hosted deployment on a dynamic IP, behind NAT, or on CGNAT cannot accept inbound calls at all.

There is nothing partial to build on today: the registered method handlers in pkg/sip/server.go are OnOptions/OnInvite/OnAck/OnBye/OnNotify/OnNoRoute, and pkg/config/config.go has no registration keys.

Why not just run a proxy

That is the workaround in #524, and it does work — Kamailio uac_reg, OpenSIPS uac_registrant, or Asterisk PJSIP in front of LiveKit SIP on localhost. We run one downstream and have taken it end to end on a live PSTN call through a real NAT. It costs a second SIP element, with its own config, container and failure modes, whose only job is to send REGISTER — in front of a service that already owns the trunk state and already knows its own signaling address. It also rewrites the source of every inbound INVITE to the proxy, so LiveKit SIP no longer sees which carrier a call came from, and the NAT details below have to be solved again in the proxy's config language.

Design

Config-file only. No livekit/protocol change, so this is reviewable and mergeable in one repo.

sip_registrations:
  - registrar: sip:sip.provider.example   # or host[:port]
    username: 1000
    password: secret
    # auth_username: 1000                 # digest username, if issued separately
    # domain: provider.example            # AOR host, defaults to the registrar host
    # expiry: 10m
    # keepalive: 25s                      # negative to disable

I did consider putting this on SIPOutboundTrunkInfo/SIPInboundTrunkInfo, which is where @lixuanqun's design comment on #524 proposes it. That is arguably the better product surface, but it is a cross-repo change, and it forces the multi-node question immediately: a trunk-level flag implies every node holding that trunk registers, so it needs owner election before it is safe. A registration is really a property of one node's connectivity to a provider rather than of a project's trunk record, so per-node config is both the smaller change and, I think, the more accurate model. The two compose — trunk-level fields can be layered on later without changing anything here. If you would rather have the protobuf version, I am glad to write that instead; I would just rather agree on it first.

Registration is a prerequisite for inbound calls, not a path for them. Once the provider has a binding, its INVITEs arrive on the existing inbound path and are authenticated and dispatched by trunk exactly as they are today. inbound.go is untouched.

What it does

pkg/sip/register.go adds one registrant per configured account, started after the server is listening and stopped before the client closes.

  • REGISTER on startup, digest auth on 401 and 407, refresh ahead of expiry, un-REGISTER with Expires: 0 on shutdown, exponential backoff on failure.
  • One Call-ID and a monotonic CSeq for the life of the registration (RFC 3261 §10.2).
  • The digest URI is the Request-URI, which for REGISTER is the registrar, not the AOR in To.
  • 423 Interval Too Brief negotiation, and the negotiated value is kept for later refreshes instead of being re-negotiated every time. (DIDWW answers 423 with Min-Expires: 600.)
  • A rejected credential (a repeated non-stale nonce, or 403) drops straight to the maximum backoff and logs at Error, rather than retrying every two seconds indefinitely.
  • Registrations are withdrawn when shutdown starts, before the drain loop, so the provider stops routing new calls to a node that is going away. That needed a small hook in pkg/service; service.NewService's signature is unchanged.

Two NAT details this has to get right, both learned the hard way downstream:

rport. Behind NAT our Via carries an address the registrar cannot reply to, so it has to ask the registrar to answer the source address and port it actually observed (RFC 3581). sipgo only adds a Via when the request has none, and the one it adds has no rport, so the registrant builds its own. Without this, registration never completes at all — the registrar answers into the void. The received/rport it echoes back is logged when it differs from the Contact we sent, which is the single most useful line for someone who is registered but not getting calls.

Keepalive. Providers commonly enforce a registration interval far longer than a NAT UDP mapping survives — 600s against a mapping usually dropped after 30s of silence. Without traffic, the mapping the provider recorded stops being reachable and inbound calls stop, while the registration still looks healthy. That is a worse failure than not registering, so I did not want to leave it purely as an operator concern: a bounded OPTIONS keepalive (25s by default, configurable, negative to disable) holds the mapping open, and an unanswered one is logged. It keeps running while a refresh is failing, because that is exactly when the mapping the provider is still calling needs to be held.

REGISTER also has to leave from the signaling socket, since the provider delivers to the mapping the REGISTER created. sipgo reuses the server listener for outbound UDP, but only once that listener is registered with the transport layer; sending earlier binds a separate socket which is then pooled for the registrar address permanently. So startRegistrations waits for the listener and fails startup if it never appears.

Registrations are per-node, and the README says to point one node at a given account. livekit_sip_registrations_active is exported per registrar, because losing a registration silently stops inbound calls.

Tests

pkg/sip/register_test.go runs a real sipgo UAS as the registrar over UDP, and drives a real registrant through a real sipgo.Client on its own signaling socket. Covered: the digest handshake and that the digest URI is the Request-URI; rport on the Via and that the REGISTER leaves from the signaling port; a separate auth_username; the 401 and the 407 paths; a stale-nonce re-challenge; Min-Expires negotiation and its persistence across a refresh; refresh with a stable Call-ID and increasing CSeq; the granted expiry read from the returned Contact, the Expires header, or the shortest listed binding; un-REGISTER on shutdown, including a challenged one; keepalives continuing through a refresh failure and stopping once the binding lapses; a rejected password not looping; and Stop aborting a REGISTER in flight rather than waiting out the transaction timeout. pkg/config/config_test.go covers the config bounds.

go test -race ./pkg/... passes.

Validating against a real carrier

Offered rather than claimed: this Go code has been tested against a local UAS, not yet against a carrier. We have DIDWW trunks in both Static Endpoint and SIP Registration mode, and we have already taken the equivalent path end to end downstream through a real NAT — REGISTER out, the carrier delivering the INVITE to the NAT-mapped port rather than the Contact literal, the call answered, two-way audio published into the room. If this is worth pursuing, I will run that same validation against this branch and post the trace.

Addresses #524. Scope is outbound registration only; accepting REGISTER as a registrar is not part of this.

Adds optional per-node SIP registration, so a deployment without a stable
public address can accept inbound calls from a provider that routes to a
registration binding, instead of needing a registering proxy in front of it.

Configured under sip_registrations: registrar, credentials, requested expiry
and a NAT keepalive interval. Registration is a prerequisite for inbound
calls, not a path for them, so INVITEs still arrive on the existing inbound
path and are authenticated and dispatched by trunk as before.

Covers digest auth on 401 and 407, refresh ahead of expiry, 423 Min-Expires
negotiation, un-REGISTER on shutdown, backoff on failure, rport on the Via so
a registrar can answer the address it observes behind NAT, and an OPTIONS
keepalive to hold that NAT mapping open between refreshes.

Refs livekit#524

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vEyHdjPUV3BrxxN2m41pT
@skyzer
skyzer requested a review from a team as a code owner August 4, 2026 00:30
@CLAassistant

CLAassistant commented Aug 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.53488% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.90%. Comparing base (0460b40) to head (65fff82).
⚠️ Report is 340 commits behind head on main.

Files with missing lines Patch % Lines
pkg/sip/register.go 85.10% 31 Missing and 21 partials ⚠️
pkg/sip/service.go 34.14% 24 Missing and 3 partials ⚠️
pkg/stats/monitor.go 58.33% 5 Missing ⚠️
pkg/service/service.go 0.00% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #774      +/-   ##
==========================================
+ Coverage   65.25%   66.90%   +1.65%     
==========================================
  Files          51       42       -9     
  Lines        6588     8376    +1788     
==========================================
+ Hits         4299     5604    +1305     
- Misses       1915     2255     +340     
- Partials      374      517     +143     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

devin-ai-integration[bot]

This comment was marked as resolved.

Label the registrations_active gauge with the address-of-record as well as
the registrar, so several accounts on one provider do not share a series and
mask each other.

Never schedule a refresh sooner than half the granted lifetime. Subtracting a
fixed margin alone made a lifetime just over that margin refresh far more
often than a slightly shorter one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vEyHdjPUV3BrxxN2m41pT
@skyzer

skyzer commented Aug 4, 2026

Copy link
Copy Markdown
Author

Thanks for the automated review — both findings were right and are fixed in 4eaf040:

  • registrations_active is now labeled by address-of-record as well as registrar, so two accounts on one provider no longer share a series and mask each other.
  • refreshAfter never returns less than half the granted lifetime. Subtracting a fixed 10s margin alone meant an 11s grant refreshed at 1s while a 10s grant refreshed at 5s; TestRefreshAfter now asserts the interval is monotonic in the lifetime.

devin-ai-integration[bot]

This comment was marked as resolved.

A challenge that fails to parse no longer has its header value repeated in the
error. It carries the registrar's nonce and opaque, and the retry loop writes
that error to the log on every attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vEyHdjPUV3BrxxN2m41pT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants